﻿//  Copyright © 2009-2010 by Rhy A. Mednick
//  All rights reserved.
//  http://rhyduino.codeplex.com
//  
//  Redistribution and use in source and binary forms, with or without modification, 
//  are permitted provided that the following conditions are met:
//  
//  * Redistributions of source code must retain the above copyright notice, this list 
//    of conditions and the following disclaimer.
//  
//  * Redistributions in binary form must reproduce the above copyright notice, this 
//    list of conditions and the following disclaimer in the documentation and/or other 
//    materials provided with the distribution.
//  
//  * Neither the name of Rhy A. Mednick nor the names of its contributors may be used 
//    to endorse or promote products derived from this software without specific prior 
//    written permission.
//  
//  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
//  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
//  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 
//  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 
//  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 
//  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 
//  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 
//  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 
//  ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 
//  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 
//  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO.Ports;
using System.Text;
using System.Threading;
using Rhyduino.Message;
using TracerX;

[assembly: CLSCompliant(true)]

namespace Rhyduino
{
    /// <summary>
    ///   The main class used for communicating with and controlling a connected device.
    /// </summary>
    public sealed class Arduino : IDisposable
    {
        #region Propertie(s)

        /// <summary>
        ///   Gets the major protocol version number reported by the remote device. 0 if not reported.
        /// </summary>
        public int FirmataMajorVersion
        {
            get
            {
                // if null, request the version from the board.
                if (_firmataMajorVersion == null)
                {
                    Post(FirmataEncoder.BuildProtocolVersionRequest());
                    // give the board about a half-second to respond.
                    var timeout = DateTime.Now + TimeSpan.FromMilliseconds(500);
                    while (_firmataMajorVersion == null && DateTime.Now > timeout)
                    {
                        Thread.Sleep(10);
                    }
                }
                if (_firmataMajorVersion == null) return 0;
                return (int) _firmataMajorVersion;
            }
        }

        /// <summary>
        ///   Gets the minor protocol version number reported by the remote device. 0 if not reported.
        /// </summary>
        public int FirmataMinorVersion
        {
            get
            {
                // if null, request the version from the board.
                if (_firmataMinorVersion == null)
                {
                    Post(FirmataEncoder.BuildProtocolVersionRequest());
                    // give the board about a half-second to respond.
                    var timeout = DateTime.Now + TimeSpan.FromMilliseconds(500);
                    while (_firmataMinorVersion == null && DateTime.Now > timeout)
                    {
                        Thread.Sleep(10);
                    }
                }
                if (_firmataMinorVersion == null) return 0;
                return (int) _firmataMinorVersion;
            }
        }

        /// <summary>
        ///   Gets the Firmata name as reported by the remote device. Null if not reported.
        /// </summary>
        public string FirmataName { get; private set; }

        /// <summary>
        ///   Gets the name of the serial port that the Arduino object is configured to use.
        /// </summary>
        public string PortName
        {
            get { return SerialPort.PortName; }
        }

        /// <summary>
        ///   Gets the baud rate that the serial port is configured to operate at.
        /// </summary>
        public int BaudRate
        {
            get { return SerialPort.BaudRate; }
        }

        // Expose serial port as protected property so that implementers can access it.

        /// <summary>
        ///   The serial port that the Arduino is configured to use.
        /// </summary>
        private SerialPort SerialPort { get; set; }


        ///<summary>
        ///  The object which controls digital pin state.
        ///</summary>
        public DigitalPins DigitalPins { get; private set; }

        ///<summary>
        ///  Provides access to the analog pin state and cache.
        ///</summary>
        public AnalogPins AnalogPins { get; private set; }

        /// <summary>
        ///   Returns true, if a connection is active between the computer and the device.
        /// </summary>
        public bool IsConnected
        {
            get { return SerialPort.IsOpen; }
        }

        ///<summary>
        ///  Occurs when the value of a digital pin changes.
        ///</summary>
        public event EventHandler<DigitalPinEventArgs> DigitalPinValueChanged;

        /// <summary>
        ///   Occurs when an outgoing message is posted on the serial port. Future versions of 
        ///   the library will contain the body of the post in the event data. For now, this 
        ///   is just used for limited monitoring of the application.
        /// </summary>
        public event EventHandler<FirmataEventArgs<FirmataMessage>> MessagePosted;

        /// <summary>
        ///   Occurs when a message containing data describing an analog pin is received.
        /// </summary>
        public event EventHandler<FirmataEventArgs<AnalogPinReport>> AnalogPinReportReceived;

        /// <summary>
        ///   Occurs when a message containing data describing the firmware version used on the 
        ///   target device is received.
        /// </summary>
        public event EventHandler<FirmataEventArgs<FirmataVersionReport>> FirmataVersionReportReceived;

        /// <summary>
        ///   Occurs when a message is received but cannot be classified. This could indicate a number
        ///   of things, none of them good. Possible reasons: 
        ///   (1) The target Firmata may be a different version than we're expecting so it's sending 
        ///   messages in a format we don't understand. 
        ///   (2) The serial port settings used by the application do not match those being used by 
        ///   the connected device.
        ///   (3) The connected device has a problem and is reporting invalid data.
        ///   (4) A flaw in this application is improperly parsing a valid message making it appear
        ///   invalid.
        /// </summary>
        public event EventHandler<FirmataEventArgs<FirmataMessage>> UnknownMessageReceived;

        #endregion

        #region Constructor(s)

        /// <summary>
        ///   Creates and initializes a new instance of the Arduino class.
        /// </summary>
        /// <param name = "portName">The name of the serial port to use. If null or empty, Rhyduino will attempt to auto 
        ///   detect it.</param>
        /// <param name = "baudRate">The baud rate to use for the serial port.</param>
        /// <param name = "autoOpen">Flag indicating if the serial connection to the device should be opened immediately. 
        ///   If false, the serial port is left closed until Rhyduino performs an operation that requires it to be open.
        /// </param>
        /// <remarks>
        ///   This, the default constructor for the Arduino class, will attempt to 
        ///   determine which serial port the target hardware is connected to. It does this 
        ///   by enumerating the serial ports on the host, opening them, and sending out a
        ///   a Firmata protocol version report request. The serial ports are opened using 
        ///   a baud rate of 57600 because this is the default value in the StandardFirmata 
        ///   sketch. If no device responds within 5 seconds, a NullReferenceException is thrown. 
        ///   When multiple devices respond, the first one in the list is used. After detection 
        ///   is complete, all ports are left in the closed state.
        /// </remarks>
        public Arduino(string portName, int baudRate = 57600, bool autoOpen = false)
        {
            //Initialize logging if thread name isn't specified.
            if (String.IsNullOrEmpty(Thread.CurrentThread.Name))
            {
                Thread.CurrentThread.Name = "Arduino";
                Logger.Xml.Configure("TraceConfig.xml");
                Logger.FileLogging.AddToListOfRecentlyCreatedFiles = true;
                Logger.FileLogging.Open();
                _log = Logger.GetLogger("Arduino");
            }

            if (String.IsNullOrEmpty(portName))
            {
                throw new ArgumentNullException("portName");
            }
            Initialize(portName, baudRate, autoOpen);
        }

        #endregion

        #region Private/Internal Methods

        internal int GetPortValue(int pinNumber)
        {
            var result = 0;

            using (_log.DebugCall())
            {
                if (pinNumber < 8)
                {
                    // Get everything from 2-7
                    for (var i = 2; i < 8; i++)
                    {
                        DigitalPinValue pinValue;
                        if (DigitalPins[i].GetPinValue(out pinValue) && (pinValue == DigitalPinValue.High))
                        {
                            result |= 1 << (i - 2);
                        }
                    }
                }
                else
                {
                    // Get everything from 8-13
                    for (var i = 8; i <= 13; i++)
                    {
                        DigitalPinValue pinValue;
                        if (DigitalPins[i].GetPinValue(out pinValue) && (pinValue == DigitalPinValue.High))
                        {
                            result |= 1 << (i - 8);
                        }
                    }
                }
                _log.Debug("pinNumber: ", pinNumber);
                _log.DebugFormat("PortValue: 0x{0:X2}", result);
            }
            return result;
        }

        private void Initialize(string portName, int baudRate, bool autoOpen)
        {
            using (_log.DebugCall())
            {
                _log.Info("portName: ", portName);
                _log.Info("baudRate: ", baudRate);
                _log.Info("autoOpen: ", autoOpen);

                DigitalPins = new DigitalPins(this);
                AnalogPins = new AnalogPins(this);

                if (baudRate > 115200)
                {
                    throw new ArgumentOutOfRangeException("baudRate", baudRate,
                                                          "Value exceeds maximum baud rate of 115200.");
                }

                SerialPort = new SerialPort(portName, baudRate);
                if (autoOpen)
                {
                    Connect();
                }
            }
        }

        /// <summary>
        ///   Occurs when a message reporting the value of an analog pin is received.
        /// </summary>
        /// <param name = "message">Message details containing the pin number and value.</param>
        private void OnAnalogPinReportReceived(AnalogPinReport message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }

                AnalogPins[message.Pin].Value = message.Value;

                if (AnalogPinReportReceived != null)
                {
                    AnalogPinReportReceived(this, new FirmataEventArgs<AnalogPinReport>(message));
                }
            }
        }

        /// <summary>
        ///   Occurs when the value of a monitored digital pin changes.
        /// </summary>
        /// <param name = "pinNumber">The pin number.</param>
        /// <param name = "digitalPinValue">The pin value.</param>
        internal void OnDigitalPinValueChange(int pinNumber, DigitalPinValue digitalPinValue)
        {
            using (_log.DebugCall())
            {
                if (DigitalPinValueChanged != null)
                {
                    DigitalPinValueChanged(this, new DigitalPinEventArgs(pinNumber, digitalPinValue));
                }
            }
        }

        /// <summary>
        ///   Occurs when a message reporting the value of a digital port is received.
        /// </summary>
        /// <param name = "message">Message details containing the port number and value.</param>
        private void OnDigitalPortReportReceived(DigitalPortReport message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }

                //Update internal storage of values
                DigitalPins.SetPortValue(message.Port, message.Value);
            }
        }

        /// <summary>
        ///   Occurs when a message reporting the remote Firmata protocol version is received.
        /// </summary>
        /// <param name = "message">Message details containing the protocol version and sketch name.</param>
        private void OnFirmataVersionReportReceived(FirmataVersionReport message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }

                if (FirmataVersionReportReceived != null)
                {
                    FirmataVersionReportReceived(this, new FirmataEventArgs<FirmataVersionReport>(message));
                }
            }
        }

        private void OnMessagePosted(FirmataMessage message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }

                if (MessagePosted != null)
                {
                    MessagePosted(this, new FirmataEventArgs<FirmataMessage>(message));
                }
            }
        }

        /// <summary>
        ///   Occurs when a message was received but was in an unrecognizable format.
        /// </summary>
        /// <param name = "message">Message details containing the received raw data.</param>
        private void OnUnknownMessageReceived(FirmataMessage message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }

                if (UnknownMessageReceived != null)
                {
                    UnknownMessageReceived(this, new FirmataEventArgs<FirmataMessage>(message));
                }
            }
        }

        [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes")]
        private void ProcessSerialData(byte[] data)
        {
            using (_log.DebugCall())
            {
                try
                {
                    // For debugging, log the data packet that was received by the method.
                    _log.Debug(data.ToHexString());

                    if (data[0] == (byte) ResponseMessageType.SysexStart)
                    {
                        var versionReport = new FirmataVersionReport(data);
                        _firmataMajorVersion = versionReport.MajorVersion;
                        _firmataMinorVersion = versionReport.MinorVersion;
                        FirmataName = versionReport.Name;
                        OnFirmataVersionReportReceived(versionReport);
                    }
                    else if ((data[0] & (byte) ResponseMessageType.AnalogValue) == (byte) ResponseMessageType.AnalogValue)
                    {
                        OnAnalogPinReportReceived(new AnalogPinReport(data));
                    }
                    else if ((data[0] & (byte) ResponseMessageType.DigitalValue) ==
                             (byte) ResponseMessageType.DigitalValue)
                    {
                        OnDigitalPortReportReceived(new DigitalPortReport(data));
                    }
                    else
                    {
                        OnUnknownMessageReceived(new FirmataMessage(data));
                    }
                }
                catch (Exception ex)
                {
                    // Many of the On???MessageReceived calls call a message constructor with the packet data
                    // and this call can fail. If it does, we don't want our application to stop, but we'd like to 
                    // know that something didn't come through correctly - Just log the error.
                    var sb = new StringBuilder();
                    sb.AppendFormat("ERROR {0} [ ", ex);
                    foreach (var b in data)
                    {
                        sb.AppendFormat("{0:X} ", b);
                    }
                    sb.Append("]");
                    _log.Warn("Packet looked valid but could not be decoded.", sb);
                }
            }
        }

        #endregion

        #region Public Methods

        /// <summary>
        ///   Open a serial connection to the attached device. No action is performed if the 
        ///   serial port is already open.
        /// </summary>
        [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")]
        public void Connect()
        {
            using (_log.DebugCall())
            {
                // Lock the serial port so that we don't have multiple threads trying to open it 
                // at the same time.
                lock (_serialPortLock)
                {
                    // Don't try to open the port if it's already open
                    if (!SerialPort.IsOpen)
                        SerialPort.Open();
                    else
                        _log.Warn("Ignored a request to open a serial port that was already open. ");

                    if (_serialListener == null)
                    {
                        // Port parameters (port name and baud rate) were set as part of constructor
                        // so all we have to do is create the serial listener
                        _serialListener = new SerialListener(SerialPort, true)
                                              {
                                                  PacketReceived = (s, e) => ProcessSerialData(e.Data.GetRawMessage())
                                              };
                    }
                }
            }
        }

        /// <summary>
        ///   Close the serial connection to the attached device.
        /// </summary>
        /// <exception cref = "InvalidOperationException">Exception is thrown when the serial port is not open.</exception>
        public void Disconnect()
        {
            using (_log.DebugCall())
            {
                // Lock the serial port so that we don't have multiple threads trying to close it 
                // at the same time. This also prevents us from closing a port that's active in 
                // another thread.
                lock (_serialPortLock)
                {
                    if (!SerialPort.IsOpen) return;

                    _serialListener.Dispose();
                    _serialListener = null;

                    SerialPort.Close();
                }
            }
        }

        /// <summary>
        ///   Posts a message to the attached device.
        /// </summary>
        /// <param name = "message">The message to send.</param>
        /// <remarks>
        ///   This is a non-blocking call. When called, this method stores the message and returns control to the caller. 
        ///   Messages are posted to the device from a separate thread.
        /// </remarks>
        public void Post(byte[] message)
        {
            using (_log.DebugCall())
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }
                if (!SerialPort.IsOpen)
                {
                    // Attempt to open the serial port
                    Connect();
                    if (!IsConnected)
                    {
                        throw new InvalidOperationException("Arduino is not connected.");
                    }
                }
                SerialPort.Write(message, 0, message.Length);
                OnMessagePosted(new FirmataMessage(message));
            }
        }

        #endregion

        #region Implementation of IDisposable interface

        #region Public Methods

        /// <summary>
        ///   Disposes the object by releasing referenced memory.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        #endregion

        #region Private Methods

        /// <summary>
        ///   Disposes the object by releasing referenced memory.
        /// </summary>
        /// <param name = "disposing" />
        private void Dispose(bool disposing)
        {
            if (_disposed)
            {
                return;
            }
            if (!disposing)
            {
                return;
            }

            // Clean up managed resources
            if (SerialPort.IsOpen)
            {
                Disconnect();
            }

            if (SerialPort != null)
            {
                SerialPort.Close();
                SerialPort.Dispose();
            }

            if (_serialListener != null)
            {
                _serialListener.Dispose();
            }
            // Clean up unmanaged resources. Set large fields to null.

            // All done
            _disposed = true;
        }

        #endregion

        #region Private Fields

        private bool _disposed;

        #endregion

        /// <summary>
        ///   Disposes the object by releasing referenced memory.
        /// </summary>
        ~Arduino()
        {
            Dispose(false);
        }

        #endregion

        #region Private Constants and Static Fields

        private static readonly object _serialPortLock = new object();
        private readonly Logger _log;

        #endregion

        #region Private Fields

        private int? _firmataMajorVersion;
        private int? _firmataMinorVersion;
        private SerialListener _serialListener;

        #endregion
    }
}